Add --remote-auth-scope-param-name for non-standard OAuth scope parameters#4712
Add --remote-auth-scope-param-name for non-standard OAuth scope parameters#4712gmogmzGithub wants to merge 1 commit intostacklok:mainfrom
Conversation
1feeca8 to
141b5cf
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4712 +/- ##
==========================================
+ Coverage 68.60% 68.62% +0.02%
==========================================
Files 517 517
Lines 54631 54639 +8
==========================================
+ Hits 37480 37498 +18
+ Misses 14265 14246 -19
- Partials 2886 2895 +9 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
2d0496d to
6506bb2
Compare
…parameters Some OAuth providers use non-standard query parameter names for scopes in the authorization URL. For example, Slack's OAuth v2 requires user-token scopes in "user_scope" instead of the standard "scope" parameter. This causes ToolHive's OAuth flow to fail with invalid_scope errors when connecting to providers like Slack's MCP server. Add a new --remote-auth-scope-param-name flag that allows users to override the query parameter name used for scopes. When set, scopes are sent under the specified parameter name and the standard "scope" parameter is cleared. The oauth2Config.Scopes field is preserved so token refresh requests continue to work correctly. Signed-off-by: Gustavo Gomez <gmogmz@indeed.com>
6506bb2 to
849326a
Compare
jhrozek
left a comment
There was a problem hiding this comment.
Review from 4 specialized agents (oauth-expert, go-security-reviewer, code-reviewer, go-expert-developer). 3 inline comments.
| // is preserved so token refresh requests still include scopes correctly. | ||
| if f.config.ScopeParamName != "" && len(f.oauth2Config.Scopes) > 0 { | ||
| opts = append(opts, | ||
| oauth2.SetAuthURLParam("scope", ""), |
There was a problem hiding this comment.
scope= empty parameter persists in the authorization URL
oauth2.SetAuthURLParam("scope", "") does not remove the scope parameter — it sets it to an empty string, producing scope= in the query string. RFC 6749 §3.3 requires scope values to have at least one character, so an explicit scope= is syntactically invalid. Some OAuth providers may reject it.
The test at flow_test.go:274 uses query.Get("scope") which returns "" for both absent and empty-valued params, masking the issue.
One approach: temporarily nil out f.oauth2Config.Scopes before calling AuthCodeURL so the library never adds scope, then restore it:
if f.config.ScopeParamName != "" && len(f.oauth2Config.Scopes) > 0 {
scopeValue := strings.Join(f.oauth2Config.Scopes, " ")
savedScopes := f.oauth2Config.Scopes
f.oauth2Config.Scopes = nil
defer func() { f.oauth2Config.Scopes = savedScopes }()
opts = append(opts,
oauth2.SetAuthURLParam(f.config.ScopeParamName, scopeValue),
)
}And update the test assertion to verify truly absent:
_, has := query["scope"]
assert.False(t, has, "scope parameter should be absent, not empty")| config.CallbackPort, | ||
| config.Resource, | ||
| config.OAuthParams, | ||
| config.ScopeParamName, |
There was a problem hiding this comment.
ScopeParamName silently ignored on OIDC discovery fallback path
ScopeParamName is correctly passed here on the manual-endpoints path, but the OIDC discovery fallback below (line ~655, CreateOAuthConfigFromOIDC) does not accept or propagate ScopeParamName. A user who sets --remote-auth-scope-param-name with --remote-auth-issuer but without explicit endpoint URLs will get standard scope= behavior with no warning.
Could set it on the returned config after the OIDC call:
cfg, err := oauth.CreateOAuthConfigFromOIDC(ctx, issuer, ...)
if err != nil { return nil, err }
cfg.ScopeParamName = config.ScopeParamName
return cfg, nil| @@ -360,14 +360,15 @@ func handleOutgoingAuthentication(ctx context.Context) (*discovery.OAuthFlowResu | |||
| } | |||
|
|
|||
| flowConfig := &discovery.OAuthFlowConfig{ | |||
There was a problem hiding this comment.
nit: duplicate OAuthFlowConfig construction
The two OAuthFlowConfig struct literals (here and at line 393) are identical — the only difference is the first argument to PerformOAuthFlow. Pre-existing, but this PR extends it by one more field. Consider extracting a helper to avoid future drift.
Summary
--remote-auth-scope-param-nameCLI flag to override the query parameter name used for scopes in the OAuth authorization URLuser_scope), scopes are sent under the custom parameter name and the standardscope=is clearedoauth2Config.Scopesis preserved so token refresh continues to work correctlyMotivation
Slack's OAuth v2 requires user-token scopes in
user_scope=instead of the standardscope=parameter. When ToolHive connects tohttps://mcp.slack.com/mcp, its OAuth discovery correctly finds thev2_user/authorizeendpoint and the 15 supported scopes, but Go'soauth2library always places scopes inscope=. Slack rejects this withinvalid_scope.This flag closes the gap so ToolHive can authenticate with providers that use non-standard scope parameter names.
Example usage for Slack MCP
Changes
pkg/auth/oauth/flow.goScopeParamNamefield onConfig;buildAuthURLoverride logicpkg/auth/oauth/manual.goscopeParamNameparameterpkg/auth/discovery/discovery.goScopeParamNameonOAuthFlowConfigpkg/auth/remote/config.goScopeParamNamefield with JSON/YAML tagspkg/auth/remote/handler.goScopeParamNameto flow configcmd/thv/app/auth_flags.go--remote-auth-scope-param-nameflagcmd/thv/app/run_flags.gocmd/thv/app/proxy.go